Blog Post
Github Actions로 배포 파이프라인 구축
GitHub ActionsNEXTERS백엔드
서론
뽀모냥 팀의 백엔드 배포 파이프라인 구축 방식을 기록해두려고한다.
크게 Docker, Github Action이라는 두가지 키워드를 중심으로 배포가 구성된다.
전체적인 순서는 다음과 같다.
- 수동 트리거 조작
- 이미지 build & push
- 이미지 pull & run
아주 대략적으로 설명한 방식이고 스크립트를 하나씩 보면서 살펴보자.
배포 스크립트를 구성해준 최고개발자 빛상운에게 무한 감사인사를 보냅니다 ⭐
수동 실행
보통 Github Action을 활용하여 배포를 수행할 때 코드가 merge 되었을 때를 일반적으로 생각했었는데 이번에는 수동으로 컨트롤하여 branch별로 배포가 가능하도록 구성할 수 있다는 것을 알았다.
Github Actions workflow에는 workflow_dispatch라는 방식이 존재한다.
이 방식은 발생하는 이벤트에 값을 같이 담아줄 수 있는 방식이다.
⚠️ 이 방식은 default branch에 github workflow가 등록되어있어야 적용이 된다.
백문이 불여일견 결과물이 어떻게 나오는지 한번 보고 스크립트를 살펴보자.

Build and Deploy Pipeline이라는 workflow에 대해서 Run workflow로 수동으로 워크플로우를 동작시킬 수 있는 것을 볼 수 있다.
각각의 설정이 어떻게 들어가있는지 확인해보자.
on:
workflow_dispatch:
inputs: # 아래 imageTag, env, deployOnly 라는 세개의 입력을 받는다.
imageTag:
description: 'Image tag'
required: true
default: 'latest' # 기본값
env:
description: 'Environment. [dev | prod]'
required: true
default: 'dev'
type: choice
options:
- dev
- prod
deployOnly:
description: 'Deploy only'
required: true
default: false
type: boolean
대부분 직관적으로 확인할 수 있는 값들이다.
description은 설명하는 글이고, required는 필수값 여부, type은 각 input이 어떤 타입으로 받는지 선언하는 부분이라고 볼 수 있겠다.
input 컨텍스트의 Type은 string, number, boolean, choice 4가지로 설정이 가능하다.
이제 우리는 이렇게 input을 받아서 각 분기별로 어떻게 처리할지를 생각하면된다.
jobs
jobs는 특정 워크플로우 내에서 실행되는 개별 작업 단위를 말한다. 여기서는 총 3개의 작업을 등록하여 사용하고있다.
배포알림
배포가 시작되었을 때 디스코드로 알림을 보내려고한다.
jobs에서는 위에서 설정한 inputs의 값들을 가져와 사용할 수 있다. 이 값들과 Github Actions에서 제공하는 기본값들, 비밀키로 설정한 값들 등을 조합하여 배포가 시작됨을 알리는 워크플로우를 구성해볼 수 있다.
jobs:
echo-inputs:
runs-on: ubuntu-latest
steps:
- name: send custom message with args
uses: tsickert/[email protected]
with:
webhook-url: ${{ secrets.DEPLOY_WEBHOOK_URL }}
embed-title: "${{ inputs.env }}에 배포 시작한다냥"
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
embed-description: |
env: ${{ inputs.env }}
targetBranch: ${{ steps.get_branch.outputs.branch }}
imageTag: ${{ inputs.imageTag }}
deployOnly: ${{ inputs.deployOnly }}
...
💡 Secret 변수 같은 경우는
Repository - Settings - Secerets and variables - Actions에서 설정할 수 있다. Secrets는 암호화되어 저장되기 때문에 설정 후 재확인이 불가능하다. 반면에 variables는 그냥 생 데이터가 저장되고 확인 가능하다. 민감정보는 Secrets로 관리하자.
discord로 webhook을 보내는 action을 활용하여 각 값을 적절하게 넣고 메시지를 전송한다.

배포 시작 알림이 잘 전송 되는것을 확인할 수 있다.
💡 기본 변수들에 대해서는 아래 자료를 참고하면 된다. 필요한 값이 있다면 공식문서를 보고 뽑아서 활용하자. 참고 자료 : Github Actions 기본 변수
이미지 build & push
배포 과정에서 Docker를 사용하기로 해서 이미지를 build하고 레지스트리에 push하는 job을 추가했다.
jobs:
...
build-image-and-push:
if: ${{ inputs.deployOnly == false }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '21'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: bootBuildImage with gradle
run: ./gradlew :clean :bootBuildImage --imageName=${{ vars.CR_ENDPOINT }}/pomonyang-api:${{ inputs.imageTag }} -x test
- name: Login to Container Registry
uses: docker/login-action@v2
with:
registry: ${{ vars.CR_ENDPOINT }}
username: ${{ secrets.CR_USERNAME }}
password: ${{ secrets.CR_PASSWORD }}
- name: Docker Push
run: docker push ${{ vars.CR_ENDPOINT }}/pomonyang-api:${{ inputs.imageTag }}
...
이 워크플로우도 직관적으로 읽어 볼 수 있겠다.
코드레벨에서 변경사항이 없고, 배포만 다시 해야하는 경우 (deployOnly가 false) 이 과정을 생략한다.
- Github Actions에서 제공하는 checkout, setup-java를 활용해서 자바 버전을 21로 설정한다.
- gradlew 를 실행하기 위해
chmod +x gradlew명령어로 실행 권한을 부여해줬다. - gradle의 bootBuildImage를 활용해서 Docker image를 빌드한다.
- 이후에는 docker Container Registry에 로그인해서 이미지를 push한다.
여기서 Container Registry로는 NCP(Navaer Cloud Platform)의 Container Registry를 사용하고있다.
bootBuildImage 참고자료: https://spring.io/guides/gs/spring-boot-docker
image pull & run
이제 도커 레지스트리에 이미지를 올렸으니 이미지를 pull 받고 컨테이너를 구동하면 된다!
jobs:
...
pull_and_run_container:
name: pull oci image and run
needs: build-image-and-push
if: |
always() &&
(needs.build-image-and-push.result == 'success' || needs.build-image-and-push.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: set stage
run: |
if [ ${{ inputs.env }} == 'dev' ]; then
echo "stage is dev"
echo "springProfile=dev" >> $GITHUB_ENV
echo "serverHost=${{ vars.DEV_SERVER_HOST }}" >> $GITHUB_ENV
echo "awsAccessKeyId=${{ secrets.DEV_SERVER_AWS_ACCESS_KEY_ID }}" >> $GITHUB_ENV
echo "awsSecretAccessKey=${{ secrets.DEV_SERVER_AWS_SECRET_ACCESS_KEY }}" >> $GITHUB_ENV
else
echo "stage is prod"
echo "springProfile=prod" >> $GITHUB_ENV
echo "serverHost=${{ vars.PROD_SERVER_HOST }}" >> $GITHUB_ENV
echo "awsAccessKeyId=${{ secrets.PROD_SERVER_AWS_ACCESS_KEY_ID }}" >> $GITHUB_ENV
echo "awsSecretAccessKey=${{ secrets.PROD_SERVER_AWS_SECRET_ACCESS_KEY }}" >> $GITHUB_ENV
fi
- name: connect ssh and deploy
uses: appleboy/ssh-action@master
with:
host: ${{ env.serverHost }}
username: ${{ secrets.GH_ACTIONS_USERNAME }}
key: ${{ secrets.GH_ACTIONS_KEY }}
passphrase: ${{ secrets.GH_ACTIONS_PASSPHRASE }}
port: ${{ vars.SSH_PORT }}
script: |
docker login -u ${{ secrets.CR_USERNAME }} -p ${{ secrets.CR_PASSWORD }} ${{ vars.CR_ENDPOINT }}
docker pull ${{ vars.CR_ENDPOINT }}/pomonyang-api:${{ inputs.imageTag }}
docker stop $(docker ps --filter "name=api-server" -a -q)
docker rm $(docker ps --filter "name=api-server" -a -q)
docker run -m 1024m --memory-swap 3g -d --name api-server --network host -v /var/logs/api-server:/workspace/logs -v /etc/localtime:/etc/localtime:ro -e DD_PROFILING_ENABLED="true" -e DD_LOGS_INJECTION="true" -e DD_ENV=${{ env.springProfile }} -e TZ="Asia/Seoul" -e SPRING_PROFILES_ACTIVE=${{ env.springProfile }} -e AWS_ACCESS_KEY_ID=${{ env.awsAccessKeyId }} -e AWS_SECRET_ACCESS_KEY=${{ env.awsSecretAccessKey }} -p 8080:8080 ${{ vars.CR_ENDPOINT }}/pomonyang-api:${{ inputs.imageTag }}
docker image prune -f
docker logout ${{ vars.CR_ENDPOINT }}
...
뭔가 많아서 어지러울수도 있지만 하나씩 차근차근 읽어보자
-
needs 블록을 통해
build-image-and-pushjob이 수행된 뒤에 실행되는 job이라고 선언했다.needs블록은 기본적으로 성공한 job에 대해서만 이어서 수행하게끔 구성되어있다.always()를 선언해서 성공하나, 안하나 항상 실행하도록 구성할 수 있다.- 여기서는 deployOnly라는 옵션이 있기 때문에 생략되는 경우에도 실행해야한다는 요구사항이 있기 때문에 always()를 이용해
success,skipped두가지 상황에 대해 성공이라고 판단한다.
-
set stageStep에서는 환경 변수로 사용할 값들을~ >> $GITHUB_ENV와 같은 형태로 저장한다.- GITHUB_ENV 환경 파일에 작성하여 뒤에서 변수를 사용할 수 있도록 도와준다.
- 여기서는
springProfile,serverHost,awsAccessKeyId,awsSecretAccessKey변수를 설정하고 있다. - Github Actions - 환경변수 설정
-
connect ssh and deployStep에서는 서버로 ssh 접속을 하여 배포를 수행하는 부분이다.- appleboy/ssh-action@master를 사용하여 ssh 접속을 수행한다.
- docker login부터 docker run, docker logout 까지 이미지를 갱신하고 컨테이너를 실행하는 명령어를 작성했다.
정리
이렇게 크게 총 3단계
- 수동 트리거 조작
- 이미지 build & push
- 이미지 pull & run
과정을 살펴봤다.
Github Action을 잘 활용하면 이런 파이프라인 환경을 무료로 구성할 수 있다는 점이 매력적인것 같다.
전체 파이프라인 코드
name: Build and Deploy Pipeline
on:
workflow_dispatch:
inputs:
imageTag:
description: 'Image tag'
required: true
default: 'latest'
env:
description: 'Environment. [dev | prod]'
required: true
default: 'dev'
type: choice
options:
- dev
# - prod 배포할 때 해제.
deployOnly:
description: 'Deploy only'
required: true
default: false
type: boolean
jobs:
echo-inputs:
runs-on: ubuntu-latest
steps:
- name: Get branch name
id: get_branch
run: echo "::set-output name=branch::${GITHUB_REF#refs/heads/}"
- name: echo inputs
run: |
echo "imageTag: ${{ inputs.imageTag }}"
echo "env: ${{ inputs.env }}"
echo "deployOnly: ${{ inputs.deployOnly }}"
- name: send custom message with args
uses: tsickert/[email protected]
with:
webhook-url: ${{ secrets.DEPLOY_WEBHOOK_URL }}
embed-title: "${{ inputs.env }}에 배포 시작한다냥"
embed-url: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
embed-description: |
env: ${{ inputs.env }}
targetBranch: ${{ steps.get_branch.outputs.branch }}
imageTag: ${{ inputs.imageTag }}
deployOnly: ${{ inputs.deployOnly }}
build-image-and-push:
if: ${{ inputs.deployOnly == false }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '21'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: bootBuildImage with gradle
run: ./gradlew :clean :bootBuildImage --imageName=${{ vars.CR_ENDPOINT }}/pomonyang-api:${{ inputs.imageTag }} -x test
- name: Login to Container Registry
uses: docker/login-action@v2
with:
registry: ${{ vars.CR_ENDPOINT }}
username: ${{ secrets.CR_USERNAME }}
password: ${{ secrets.CR_PASSWORD }}
- name: Docker Push
run: docker push ${{ vars.CR_ENDPOINT }}/pomonyang-api:${{ inputs.imageTag }}
pull_and_run_container:
name: pull oci image and run
needs: build-image-and-push
if: |
always() &&
(needs.build-image-and-push.result == 'success' || needs.build-image-and-push.result == 'skipped')
runs-on: ubuntu-latest
steps:
- name: set stage
run: |
if [ ${{ inputs.env }} == 'dev' ]; then
echo "stage is dev"
echo "springProfile=dev" >> $GITHUB_ENV
echo "serverHost=${{ vars.DEV_SERVER_HOST }}" >> $GITHUB_ENV
echo "awsAccessKeyId=${{ secrets.DEV_SERVER_AWS_ACCESS_KEY_ID }}" >> $GITHUB_ENV
echo "awsSecretAccessKey=${{ secrets.DEV_SERVER_AWS_SECRET_ACCESS_KEY }}" >> $GITHUB_ENV
else
echo "stage is prod"
echo "springProfile=prod" >> $GITHUB_ENV
echo "serverHost=${{ vars.PROD_SERVER_HOST }}" >> $GITHUB_ENV
echo "awsAccessKeyId=${{ secrets.PROD_SERVER_AWS_ACCESS_KEY_ID }}" >> $GITHUB_ENV
echo "awsSecretAccessKey=${{ secrets.PROD_SERVER_AWS_SECRET_ACCESS_KEY }}" >> $GITHUB_ENV
fi
- name: connect ssh and deploy
uses: appleboy/ssh-action@master
with:
host: ${{ env.serverHost }}
username: ${{ secrets.GH_ACTIONS_USERNAME }}
key: ${{ secrets.GH_ACTIONS_KEY }}
passphrase: ${{ secrets.GH_ACTIONS_PASSPHRASE }}
port: ${{ vars.SSH_PORT }}
script: |
docker login -u ${{ secrets.CR_USERNAME }} -p ${{ secrets.CR_PASSWORD }} ${{ vars.CR_ENDPOINT }}
docker pull ${{ vars.CR_ENDPOINT }}/pomonyang-api:${{ inputs.imageTag }}
docker stop $(docker ps --filter "name=api-server" -a -q)
docker rm $(docker ps --filter "name=api-server" -a -q)
docker run -m 1024m --memory-swap 3g -d --name api-server --network host -v /var/logs/api-server:/workspace/logs -v /etc/localtime:/etc/localtime:ro -e DD_PROFILING_ENABLED="true" -e DD_LOGS_INJECTION="true" -e DD_ENV=${{ env.springProfile }} -e TZ="Asia/Seoul" -e SPRING_PROFILES_ACTIVE=${{ env.springProfile }} -e AWS_ACCESS_KEY_ID=${{ env.awsAccessKeyId }} -e AWS_SECRET_ACCESS_KEY=${{ env.awsSecretAccessKey }} -p 8080:8080 ${{ vars.CR_ENDPOINT }}/pomonyang-api:${{ inputs.imageTag }}
docker image prune -f
docker logout ${{ vars.CR_ENDPOINT }}

